Lambda関数でメモリサイズよりも大きいファイルを圧縮してみた

Lambda関数でメモリサイズよりも大きいファイルを圧縮してみた

メモリに載せるデータ量を調整して処理しよう
Clock Icon2025.01.14

メモリサイズよりも大きいファイルを圧縮したい

こんにちは、のんピ(@non____97)です。

皆さんはLambda関数でメモリサイズよりも大きいファイルを圧縮したいなと思ったことはありますか? 私はあります。

Lambda関数を用いてAurora PostgreSQLのログファイルをS3バケットへPUTする方法を紹介しました。

https://dev.classmethod.jp/articles/aurora-postgresql-logs-direct-to-s3/

Lambda関数上では圧縮を行なっています。

こちらの記事にて紹介しているとおり、メモリサイズ以上のログファイルを圧縮しようとした場合、以下MemoryErrorが出力されます。

{
    "level": "ERROR",
    "location": "_compress_file:82",
    "message": "Failed to compress file",
    "timestamp": "2025-01-12 21:58:00,014+0000",
    "service": "rds-log-file-uploader",
    "cold_start": false,
    "function_name": "AuroraPostgresqlLogArchiv-LambdaConstructRdsLogFil-iJZNuDkxaFnp",
    "function_memory_size": "128",
    "function_arn": "arn:aws:lambda:us-east-1:<AWSアカウントID>:function:AuroraPostgresqlLogArchiv-LambdaConstructRdsLogFil-iJZNuDkxaFnp",
    "function_request_id": "0b382ab8-0756-45bf-9d9a-d9ed4acd3ed5",
    "file_path": "/tmp/tmpelazbqeh",
    "error": "",
    "exception": "Traceback (most recent call last):\n  File \"/var/task/rds_log_file_uploader.py\", line 66, in _compress_file\n    f_out.write(f_in.read())\n                ~~~~~~~~~^^\nMemoryError",
    "exception_name": "MemoryError",
    "stack_trace": {
        "type": "MemoryError",
        "value": "",
        "module": "builtins",
        "frames": [
            {
                "file": "/var/task/rds_log_file_uploader.py",
                "line": 66,
                "function": "_compress_file",
                "statement": "f_out.write(f_in.read())"
            }
        ]
    },
    "xray_trace_id": "1-67843ab6-31e0be55ff6eb2c44aa5ba3d"
}

ログファイルサイズはlog_rotation_sizeで制御可能ですが、コスト観点からメモリサイズを小さくしたい場面もあるでしょう。

そのような場面にも対応できるようにLambda関数を修正してみました。

(メモリサイズを増やすことで処理時間が短くなり、結果としてトータルのコストが安くなることもあるという話は一旦置いておきます)

https://aws.amazon.com/jp/blogs/news/operating-lambda-performance-optimization-part-2/

チャンクごとに分割して圧縮処理を行う

圧縮処理を行う際、従来では以下のようにファイル全体を読み込んだ上で圧縮していました。

# 圧縮
with open(file_path, "rb") as f_in:
    with gzip.open(temp_path, "wb", compresslevel=6) as f_out:
        f_out.write(f_in.read())

このタイミングで、以下のようにLambda関数でOutOfMemoryのエラーが出力されることがあります。

{
    "time": "2025-01-12T22:44:38.770Z",
    "type": "platform.runtimeDone",
    "record": {
        "requestId": "fc2dbe1e-a14d-4460-b66a-2828d020bb22",
        "status": "error",
        "errorType": "Runtime.OutOfMemory",
        "tracing": {
            "spanId": "be42d47a06a45c3a",
            "type": "X-Amzn-Trace-Id",
            "value": "Root=1-678445b8-3c6a69faee23b2abbd6e6452;Parent=23a71519c503dbee;Sampled=1;Lineage=1:b8e6c7fb:0"
        },
        "metrics": {
            "durationMs": 20520.454,
            "producedBytes": 0
        }
    }
}
{
    "time": "2025-01-12T22:44:38.800Z",
    "type": "platform.report",
    "record": {
        "requestId": "fc2dbe1e-a14d-4460-b66a-2828d020bb22",
        "metrics": {
            "durationMs": 20550.886,
            "billedDurationMs": 20551,
            "memorySizeMB": 128,
            "maxMemoryUsedMB": 125,
            "initDurationMs": 460.054
        },
        "tracing": {
            "spanId": "be42d47a06a45c3a",
            "type": "X-Amzn-Trace-Id",
            "value": "Root=1-678445b8-3c6a69faee23b2abbd6e6452;Parent=23a71519c503dbee;Sampled=1;Lineage=1:b8e6c7fb:0"
        },
        "status": "error",
        "errorType": "Runtime.OutOfMemory"
    }
}

一度にファイル全体を読み込もうとするため、エラーになるのであれば、ある程度の区切り(チャンク)に分割して読み込むことで回避することが可能です。

チャンクサイズは大きければIO数が少なくなりますし、圧縮効率も良くなりそうです。一方で大きければ、その分メモリエラーになる可能性も高くなります。

そのため、今回はLambda関数のメモリサイズの1/8をチャンクサイズとして使用しました。

Lambda関数ではAWS_LAMBDA_FUNCTION_MEMORY_SIZEという環境変数でメモリサイズを取得することが可能です。ランタイム環境変数は以下AWS公式ドキュメントをご覧ください。

https://docs.aws.amazon.com/ja_jp/lambda/latest/dg/configuration-envvars.html#configuration-envvars-runtime

具体的な処理は以下のようになります。

# Lambda関数のメモリサイズの1/8をチャンクサイズとして使用(バイト単位)
chunk_size = (
    int(os.environ.get("AWS_LAMBDA_FUNCTION_MEMORY_SIZE")) * 1024 * 1024
) // 8

logger.debug(
    "Compressing file with chunks",
    extra={
        "file_path": file_path,
        "original_size": original_size,
        "chunk_size": chunk_size,
    },
)

# チャンク単位で圧縮
with open(file_path, "rb") as f_in:
    with gzip.open(temp_path, "wb", compresslevel=6) as f_out:
        while True:
            chunk = f_in.read(chunk_size)
            if not chunk:
                break
            f_out.write(chunk)

メモリサイズを1/2や1/3、1/4などにした場合は以下のように[Errno 14] Bad addressとなることが稀にあったので1/8としています。

{
    "level": "ERROR",
    "location": "_compress_file:103",
    "message": "Failed to compress file",
    "timestamp": "2025-01-13 00:30:17,807+0000",
    "service": "rds-log-file-uploader",
    "cold_start": true,
    "function_name": "AuroraPostgresqlLogArchiv-LambdaConstructRdsLogFil-iJZNuDkxaFnp",
    "function_memory_size": "128",
    "function_arn": "arn:aws:lambda:us-east-1:<AWSアカウントID>:function:AuroraPostgresqlLogArchiv-LambdaConstructRdsLogFil-iJZNuDkxaFnp",
    "function_request_id": "e5fa3142-1ae7-44f4-a283-13381936d43b",
    "file_path": "/tmp/tmpgldv55xj",
    "error": "[Errno 14] Bad address",
    "exception": "Traceback (most recent call last):\n  File \"/var/task/rds_log_file_uploader.py\", line 82, in _compress_file\n    chunk = f_in.read(chunk_size)\nOSError: [Errno 14] Bad address",
    "exception_name": "OSError",
    "stack_trace": {
        "type": "OSError",
        "value": "[Errno 14] Bad address",
        "module": "builtins",
        "frames": [
            {
                "file": "/var/task/rds_log_file_uploader.py",
                "line": 82,
                "function": "_compress_file",
                "statement": "chunk = f_in.read(chunk_size)"
            }
        ]
    },
    "xray_trace_id": "1-67845e72-a91a86430064896d7487dda1"
}

1/8にしてから、合計100ファイルほどメモリサイズよりも大きいパターンを試しましたが、[Errno 14] Bad addressにはなっていません。

やってみた

既存オブジェクトの削除

実際に試してみます。

検証環境は前回記事と同じものを流用します。

Aurora PostgreSQLのログをCloudWatch Logsを経由せずに直接S3バケットにPUTしてみた検証環境構成図2.png

まず、圧縮前で200MBのオブジェクトを削除します。

オブジェクトを削除する前に、チャンクごとに圧縮したことが原因で欠損が起きないか確認するためにAthenaでログレコードの件数を確認しておきます。

SELECT count(*)
FROM 
  postgresql_logs
WHERE
  datehour>='2025/01/11/22'
# _col0
1 19094642

既存オブジェクトを削除します。

3.オブジェクトを削除.png

メモリサイズ128MBで最大200MBのファイルに対して圧縮

それでは、メモリサイズ128MBで最大200MBのファイルに対して圧縮を行います。

Lambda関数のメモリサイズを128MBに変更します。

4_メモリサイズを128MBに変更.png

この状態でステートマシンを実行します。実行時のパラメーターは以下のとおりです。

{
  "DbClusterIdentifier": "database-1",
  "LogDestinationBucket": "aurora-postgresql-log",
  "LogRangeMinutes": 8800
}

1分50秒弱で完了しました。

7.1分47秒で完了.png

オブジェクトのサイズを見ていると3.0MBと、削除前のオブジェクトと同じサイズでした。正常に圧縮されていそうですね。

8.ログファイルのサイズが3.0MB.png

Lambda関数が出力したログからも200MBのファイルを圧縮して3MBほどになっていることが確認できます。

{
    "level": "INFO",
    "location": "_compress_file:99",
    "message": "Successfully compressed file",
    "timestamp": "2025-01-14 01:47:20,859+0000",
    "service": "rds-log-file-uploader",
    "cold_start": false,
    "function_name": "AuroraPostgresqlLogArchiv-LambdaConstructRdsLogFil-iJZNuDkxaFnp",
    "function_memory_size": "128",
    "function_arn": "arn:aws:lambda:us-east-1:<AWSアカウントID>:function:AuroraPostgresqlLogArchiv-LambdaConstructRdsLogFil-iJZNuDkxaFnp",
    "function_request_id": "3bafe986-a7f6-4c05-a695-cd90e62638df",
    "file_path": "/tmp/tmpzywq984o",
    "original_size": 204800698,
    "compressed_size": 3193423,
    "compression_ratio": "1.56%",
    "xray_trace_id": "1-6785c1d5-61b4ab7541564c9b079b59e9"
}

S3バケットにPUTされたことを確認したのでAthenaでクエリをかけてみます。

SELECT count(*)
FROM 
  postgresql_logs
WHERE
  datehour>='2025/01/11/22'
# _col0
1 19094642

件数はオブジェクト削除前と全く同じですね。クエリキャッシュを利用するようにしていないので、確かに正常に圧縮できていることが分かります。

もちろん他のクエリも叩けられます。

10.Athenaでクエリもかけられる.png

次にX-Rayのトレース結果を見て、圧縮処理にどの程度時間が掛かっているのか確認してみます。

9.X-Rayのタイムラインを見ても保留中ばかり.png

ログファイルを圧縮してS3バケットにPUTするLambda関数が軒並み保留中となっています。数時間待っても結果は表示されませんでした。

その後、同じログファイルが対象となるように繰り返し実行しまいしたが、いずれも保留中のままでした。メモリ負荷が高すぎてLambda関数からX-RayにPUTできていないのでしょうか。

メモリサイズ1,024MBで最大200MBのファイルに対して圧縮

続いてメモリサイズを1,024MBにして、同じログファイルを圧縮したときの実行時間を確認してみます。

Lambda関数のメモリサイズを変更します。

11.メモリサイズを1024MBに変更.png

この状態でステートマシンを実行します。

12.メモリサイズ1024MBの場合でも1分かかった.png

1分ほど掛かってしまいました。

前回記事では30秒ほどで完了していました。

どこで時間がかかっているのか調査するためにX-Rayのトレース結果を確認してみましょう。

13.圧縮処理は3秒程度.png

ログファイルをDBインスタンスからダウンロードするのに45秒ほどかかっているようですね。前回記事では12秒ほどで完了していました。S3バケットへのPUTするのにかかった時間は変わらず1秒未満であることから、Lambda関数のネットワーク帯域ではなく、Aurora Serverless v2側が調子悪いのではないかと想像します。

肝心の圧縮処理にかかった時間は3秒ほどでした。前回記事でも約3秒だったので圧縮速度に大きな違いはないようです。

ちなみに、もちろん全てのオブジェクトが圧縮されていました。

14.圧縮されていることを確認.png

メモリサイズ1,024MBで最大1GBのファイルに対して圧縮

続いて、メモリサイズ1,024MBで最大1GBのファイルに対して圧縮をしてみます。

log_rotation_sizeの最大は1GBです。つまりは1GB以上のログファイルは生成されません。

1GBのログファイルであってもLambda関数上で圧縮できることを確認します。

事前準備として、log_rotation_agelog_rotation_sizeを最大に変更します。

15.log_rotation_ageとlog_rotation_sizeを最大に変更.png

その後、DBに大量に書き込んで1GBのログファイルを3つ出力されるまで待ちます。

16.1GBのログファイルを用意.png

ログファイルを抱え切れるように、Lambda関数でエフェメラルストレージとタイムアウト値を変更しておきます。

17_エフェメラルストレージとタイムアウト値を修正.png

ステートマシンを実行します。

18.1GBのログファイルを圧縮する場合.png

1分弱で完了しました。思ったより早いです。

S3バケットを確認すると、1GBのログファイルが15.2MBのオブジェクトとしてPUTされていました。

19.15.2MBに圧縮されていることを確認.png

Lambda関数の圧縮時のログは以下の通りで、確かに1GBのログファイルを15MBほどに圧縮したことが分かります。

{
    "level": "INFO",
    "location": "_compress_file:99",
    "message": "Successfully compressed file",
    "timestamp": "2025-01-14 02:53:53,385+0000",
    "service": "rds-log-file-uploader",
    "cold_start": true,
    "function_name": "AuroraPostgresqlLogArchiv-LambdaConstructRdsLogFil-iJZNuDkxaFnp",
    "function_memory_size": "1024",
    "function_arn": "arn:aws:lambda:us-east-1:<AWSアカウントID>:function:AuroraPostgresqlLogArchiv-LambdaConstructRdsLogFil-iJZNuDkxaFnp",
    "function_request_id": "e4869872-1790-4805-9846-49319a513271",
    "file_path": "/tmp/tmpevez8n2f",
    "original_size": 1024005535,
    "compressed_size": 15964528,
    "compression_ratio": "1.56%",
    "xray_trace_id": "1-6785d18c-4aca63643f9f5a3bc438eebf"
}

圧縮時間を確認するためにX-Rayのトレース結果を確認しましょう。

20.1GBのログファイルを圧縮する場合のトレース結果.png

15秒ほどですね。体感速いような気がします。

メモリ使用量も確認しましょう。

CloudWatch Logs InsightsでLambda関数のメモリ使用量を確認します。

  filter @type = "REPORT"
| stats 
    min(record.metrics.maxMemoryUsedMB) as minMemory, 
    max(record.metrics.maxMemoryUsedMB) as maxMemory,
    avg(record.metrics.maxMemoryUsedMB) as avgMemory,
    pct(record.metrics.maxMemoryUsedMB, 60) as p60Memory,
    pct(record.metrics.maxMemoryUsedMB, 70) as p70Memory,
    pct(record.metrics.maxMemoryUsedMB, 80) as p80Memory,
    pct(record.metrics.maxMemoryUsedMB, 90) as p90Memory
minMemory maxMemory avgMemory p60Memory p70Memory p80Memory p90Memory
1010 1011 1010.6667 1011 1011 1011 1011

メモリを最大限使っていることが分かります。

メモリサイズ128MBで最大1GBのファイルに対して圧縮

最後にメモリサイズ128MBで最大1GBのファイルに対して圧縮をかけられるのか確認してみます。

Lambda関数のメモリサイズを128MBに変更してステートマシンを実行します。

21.128MBのメモリでも1GBのログファイルを捌くことができる.png

すると、3分ほどで完了しました。

S3バケットを除くと前回実行時と同じく15.2MBに圧縮されていることが分かります。

22.圧縮量は変わっていないことを確認.png

Lambda関数の圧縮時のログは以下の通りで、確かにメモリサイズ128MBのLambda関数で1GBのログファイルを15MBほどに圧縮したことが分かります。

{
    "level": "INFO",
    "location": "_compress_file:99",
    "message": "Successfully compressed file",
    "timestamp": "2025-01-14 03:04:55,772+0000",
    "service": "rds-log-file-uploader",
    "cold_start": true,
    "function_name": "AuroraPostgresqlLogArchiv-LambdaConstructRdsLogFil-iJZNuDkxaFnp",
    "function_memory_size": "128",
    "function_arn": "arn:aws:lambda:us-east-1:<AWSアカウントID>:function:AuroraPostgresqlLogArchiv-LambdaConstructRdsLogFil-iJZNuDkxaFnp",
    "function_request_id": "8a7acf45-3380-4337-be04-0a84a7d014e5",
    "file_path": "/tmp/tmpf5yhjf_8",
    "original_size": 1024005535,
    "compressed_size": 15964528,
    "compression_ratio": "1.56%",
    "xray_trace_id": "1-6785d3a1-46ca6b392d73f29b3ed490ba"
}

Aurora PostgreSQLのログファイルの最大サイズである1GBをメモリサイズ128MBのLambda関数でも捌けられたので、どんなAurora PostgreSQLでも対応できそうですね。

X-Rayのトレース結果は以下のとおりです。

23.128MBのメモリでも1GBのログファイルを捌くことができる場合のトレース結果.png

おおよそ圧縮するのに2分ほどかかっていますね。

Lambdaはメモリの割り当てサイズに応じて利用可能なCPUクロック周波数やNW帯域も比例して大きくなっていきます。

メモリを追加すると、CPU の処理量が比例的に増加して、計算能力全体が向上します。関数が CPU、ネットワーク、またはメモリにバインドされている場合、メモリ設定を増やすとパフォーマンスが大幅に向上する可能性があります。

Lambda 関数のメモリを設定 - AWS Lambda

そのため、処理に時間がかかるようになったのでしょう。

同様にDBインスタンスからログファイルをダウンロードするのにも55秒ほどかかるようになっています。(1,024MBの場合は30秒ほど)

Lambda関数のメモリ使用量も確認します。

filter @type = "REPORT"
| stats 
    min(record.metrics.maxMemoryUsedMB) as minMemory, 
    max(record.metrics.maxMemoryUsedMB) as maxMemory,
    avg(record.metrics.maxMemoryUsedMB) as avgMemory,
    pct(record.metrics.maxMemoryUsedMB, 60) as p60Memory,
    pct(record.metrics.maxMemoryUsedMB, 70) as p70Memory,
    pct(record.metrics.maxMemoryUsedMB, 80) as p80Memory,
    pct(record.metrics.maxMemoryUsedMB, 90) as p90Memory
minMemory maxMemory avgMemory p60Memory p70Memory p80Memory p90Memory
125 125 125 125 125 125 125

はい、125MBと限界ギリギリまで使っていますね。

メモリに載せるデータ量を調整して処理しよう

Lambda関数でメモリサイズよりも大きいファイルを圧縮してみました。

AWS SDK for pandas (awswrangler)などで大きなデータを処理するときにでも使えそうですね。

とは言え、メモリギリギリを攻めるのは推奨されることではありません。

Lambda Insightsを導入するなどしてメモリ使用量をチェックし、メモリ消費が減るようなコードに変更する対応が必要でしょう。

https://dev.classmethod.jp/articles/lambda-insights-structure-usage/

今回使用したコードは以下リポジトリに保存しています。

https://github.com/non-97/aurora-postgresql-log-archive/releases/tag/v1.1.0

この記事が誰かの助けになれば幸いです。

以上、クラウド事業本部 コンサルティング部の のんピ(@non____97)でした!

Share this article

facebook logohatena logotwitter logo

© Classmethod, Inc. All rights reserved.